PAT-1788 kai-preview iframe auth on apps-proxy#2601
Conversation
Add KaiPreview struct with HandshakeSigningKey, SessionSigningKey, SessionTTL (default 4h), and AllowedIDEOrigins fields. Wire it into Config and add tests for defaults and required-field validation.
Add RefreshHandler that validates the session cookie JWT, mints a fresh JWT (with random jti for uniqueness), and returns 204 with the new session cookie set. CORS headers are emitted before auth checks so the SPA can read failure status codes from allowed origins. Also adds a random jti to MintSessionJWT to ensure each minted token is unique even when the clock hasn't advanced.
- Add StorageAPIURL to config.Config (required field, defaults to
connection.keboola.com) so the STA verifier can be constructed.
- Add clock and staVerifier to apphandler.Manager; constructed in
NewManager using d.Clock() and kaipreview.NewSTAVerifier().
- Add kaiPreview field to appHandler; constructed per-app in
newAppHandler with a DevModeCheckerFunc backed by the live K8s
state watcher.
- Insert kai-preview routing decisions in serveHTTPOrError (between
the hostname-redirect and the existing internal URL routing):
1. /_proxy/kai-preview/* -> kai-preview composite handler
2. Valid session cookie -> forward to upstream, skip AuthRules
(TODO T15: sliding refresh)
3. Sec-Fetch-Dest=iframe with no session -> serve bootstrap shim
- Falls through to existing AuthRules when DevMode is false.
- Add isDevMode() helper that re-reads live K8s cache per request.
- Update mocked dependency scope to auto-populate StorageAPIURL and
KaiPreview signing keys for test configurations.
- Add scaffolded integration test (t.Skip) in apphandler package.
- Add three real integration tests to proxy_test.go: bootstrap on
dev-mode app, fall-through on non-dev-mode app, and iframe
bootstrap fallback detection.
…eAPIURL at startup Extend DevModeChecker.IsDevMode to accept a context.Context so that request trace IDs propagate through to AppInfo log lines instead of being dropped via context.Background(). Update DevModeCheckerFunc, all three endpoint handlers (embed_token, exchange, refresh), their test stubs, and the apphandler closure. Replace the StorageAPIURL nil-guard in manager.NewManager with an explicit panic so misconfiguration surfaces at startup rather than silently producing an empty URL. Delete the scaffolded kaipreview_integration_test.go; real integration tests belong in proxy_test.go alongside the existing kai-preview fixtures.
When a request arrives with a valid kai-preview session cookie whose age has passed the TTL midpoint (NeedsRefresh == true), the proxy mints a fresh JWT and emits Set-Cookie on the response before forwarding to upstream. Replaces the TODO(T15) stub added in T14. Adds TestKaiPreviewSlidingRefresh: a focused regression test that injects a FakeClock, asserts no Set-Cookie before midpoint (t+1h / 4h TTL), and confirms Set-Cookie with a valid fresh JWT appears after midpoint (t+3h).
Add operator-facing documentation for the kai-preview iframe-auth flow, covering endpoint reference, config keys, routing decision tree, multi-replica stateless behavior, and a step-by-step smoke-test runbook (Tasks 16 + 17).
… dev-mode gate Strip trailing slashes from AllowedIDEOrigins in KaiPreview.Normalize() to prevent silent CORS failures when operators configure origins with a trailing slash (browsers send Origin headers without one). Add an internal IsDevMode guard to BootstrapHandler matching the pattern of the other three handlers.
There was a problem hiding this comment.
Pull request overview
Adds a new dev-mode-only “kai-preview” iframe authentication flow to apps-proxy so kbc-ui can embed running data apps without triggering the app’s configured OAuth/Basic auth prompt, while keeping direct navigation unchanged.
Changes:
- Introduces a new
/_proxy/kai-preview/*endpoint suite (embed-token/bootstrap/exchange/refresh) with stateless JWT handshake + partitioned session cookie. - Extends app routing to (a) route kai-preview endpoints, (b) accept a valid kai-preview session cookie and bypass AuthRules, and (c) serve a bootstrap shim for iframe document loads without a session.
- Adds configuration keys, docs, and extensive unit/integration tests (including sliding refresh behavior via an injected fake clock).
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/pkg/service/appsproxy/proxy/proxy_test.go | Adds router/integration regression tests for kai-preview bootstrap and sliding-refresh behavior. |
| internal/pkg/service/appsproxy/proxy/apphandler/manager.go | Injects a clock into apphandler manager and wires in STA token verification support. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml | Adds the bootstrap HTML shim that performs the postMessage handshake and token exchange. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go | Implements Storage API token verification used by the embed-token endpoint. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go | Unit tests for STA verification behavior and error handling. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go | Implements the CORS “heartbeat” refresh endpoint that re-mints the session cookie. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go | Unit tests for refresh endpoint (CORS, dev-mode gating, cookie validation). |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go | Implements handshake/session JWT minting and verification logic. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go | Unit tests for JWT round-trips, expiry, and refresh midpoint detection. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go | Adds heuristic detection of iframe document loads (Sec-Fetch-Dest + Accept). |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go | Unit tests for iframe document load detection. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go | Adds composite per-app handler routing kai-preview subpaths to dedicated handlers. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go | Unit tests verifying composite handler routes requests to the right sub-handler. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go | Implements handshake-token exchange for a host-only partitioned session cookie. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go | Unit tests for token exchange behavior and dev-mode gating. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go | Implements CORS embed-token minting via STA verification + handshake JWT issuance. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go | Unit tests for embed-token endpoint (CORS, STA errors, dev-mode gating). |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go | Adds frame-ancestors CSP helper for the bootstrap shim responses. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go | Unit tests for CSP header generation. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go | Adds a small CORS helper for embed-token/refresh endpoints. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go | Unit tests for CORS preflight and response header behavior. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go | Adds session cookie helpers (set/clear/read/validate) for kai-preview sessions. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go | Unit tests for cookie attributes and validation behavior. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go | Implements bootstrap handler serving the HTML shim + CSP. |
| internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go | Unit tests for bootstrap shim HTML content and CSP behavior. |
| internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go | Adds dev-mode kai-preview routing, session-cookie bypass, and iframe bootstrap fallback. |
| internal/pkg/service/appsproxy/dependencies/mocked.go | Extends mocked scope defaults to include kai-preview config and storageApiUrl. |
| internal/pkg/service/appsproxy/config/config.go | Introduces kaiPreview config block + storageApiUrl and normalization for allowed origins. |
| internal/pkg/service/appsproxy/config/config_test.go | Adds tests for kai-preview defaults, required keys, and normalization. |
| go.mod | Promotes github.com/golang-jwt/jwt/v5 to a direct dependency. |
| docs/apps-proxy/kai-preview.md | Adds operator documentation/runbook for configuring and verifying kai-preview. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // kai-preview: dev-mode iframe-auth path. | ||
| // (routing decision documented in spec § "apps-proxy: routing decision for dev-mode apps") | ||
| if h.isDevMode(req.Context()) { | ||
| // 1. /_proxy/kai-preview/* routes go to the kai-preview composite handler. | ||
| if strings.HasPrefix(req.URL.Path, kaipreview.PathPrefix) { | ||
| return h.kaiPreview.ServeHTTPOrError(w, req) | ||
| } |
| // kai-preview: GET /_proxy/kai-preview/bootstrap on a non-dev-mode app falls through to | ||
| // AuthRules. The "devmode" app has AuthRequired=false, so the upstream is reached — the | ||
| // kai-preview bootstrap shim is NOT served. | ||
| name: "kai-preview-bootstrap-non-dev-mode", | ||
| run: func(t *testing.T, client *http.Client, m []*mockoidc.MockOIDC, appServer *testutil.AppServer, service *testutil.DataAppsAPI, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { | ||
| // "devmode" app has DevMode=false by default (makeDefaultK8sObjects does not set devMode). | ||
| request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://dev-devmode.hub.keboola.local/_proxy/kai-preview/bootstrap", nil) | ||
| require.NoError(t, err) | ||
| response, err := client.Do(request) | ||
| require.NoError(t, err) | ||
| // With DevMode=false the request falls through to the AuthRules path. | ||
| // The "devmode" app is public (AuthRequired=false), so the upstream responds directly. | ||
| require.Equal(t, http.StatusOK, response.StatusCode) |
| func (v *STAVerifier) Verify(ctx context.Context, token string) (*STAVerifyResult, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.baseURL+"/v2/storage/tokens/verify", nil) | ||
| if err != nil { | ||
| return nil, errors.Errorf("kai-preview: build STA verify request: %w", err) | ||
| } | ||
| req.Header.Set("X-StorageApi-Token", token) |
| func (c *CORS) WriteResponseHeaders(w http.ResponseWriter, origin string) { | ||
| if !c.IsAllowed(origin) { | ||
| return | ||
| } | ||
| w.Header().Set("Access-Control-Allow-Origin", origin) | ||
| w.Header().Set("Access-Control-Allow-Credentials", "true") | ||
| w.Header().Set("Vary", "Origin") | ||
| } |
| // ClearSessionCookie writes a cookie that invalidates any existing kai-preview | ||
| // session cookie on the same host. Used by the exchange endpoint on validation | ||
| // failure and by future sign-out flows. | ||
| func ClearSessionCookie(w http.ResponseWriter) { | ||
| http.SetCookie(w, &http.Cookie{ | ||
| Name: SessionCookieName, | ||
| Value: "", | ||
| Path: "/", | ||
| Secure: true, | ||
| HttpOnly: true, | ||
| SameSite: http.SameSiteNoneMode, | ||
| Partitioned: true, | ||
| MaxAge: -1, | ||
| }) |
| func (c *Config) Normalize() { | ||
| } | ||
|
|
||
| func (c *KaiPreview) Normalize() { | ||
| for i, o := range c.AllowedIDEOrigins { | ||
| c.AllowedIDEOrigins[i] = strings.TrimRight(o, "/") | ||
| } | ||
| } |
| func NewBootstrapHandler(allowedIDEOrigins []string, devMode DevModeChecker, appID string) *BootstrapHandler { | ||
| bs, _ := json.Marshal(allowedIDEOrigins) // []string round-trip never errors for []string | ||
| return &BootstrapHandler{ | ||
| allowedIDEOrigins: allowedIDEOrigins, | ||
| originsJSON: template.JS(bs), | ||
| devMode: devMode, | ||
| appID: appID, | ||
| } |
| | Endpoint | Method | Auth | Notes | | ||
| |---|---|---|---| | ||
| | `/_proxy/kai-preview/embed-token` | `POST` | `X-StorageApi-Token` header (CORS) | Mint a 60 s handshake JWT after verifying the STA token against Storage API | | ||
| | `/_proxy/kai-preview/bootstrap` | `GET` | none | Return the postMessage handshake shim HTML; sets `Content-Security-Policy: frame-ancestors <allowed-origins>` | | ||
| | `/_proxy/kai-preview/exchange` | `POST` | JWT in JSON body `{"token":"..."}` | Verify handshake JWT, set the `kbc-kai-preview-session` session cookie | | ||
| | `/_proxy/kai-preview/refresh` | `POST` | session cookie (CORS) | Re-mint and slide the session cookie; returns `204 No Content` | |
| | Key | Default | Notes | | ||
| |---|---|---| | ||
| | `kaiPreview.handshakeSigningKey` | *(required)* | HMAC-SHA256 key for the 60 s handshake JWT | | ||
| | `kaiPreview.sessionSigningKey` | *(required)* | HMAC-SHA256 key for the session cookie JWT | | ||
| | `kaiPreview.sessionTTL` | `4h` | Sliding session cookie lifetime | | ||
| | `kaiPreview.allowedIdeOrigins` | *(required)* | Origins permitted to call `embed-token` and `refresh`, e.g. `https://connection.keboola.com` | | ||
| | `storageApiUrl` | `https://connection.keboola.com` | Storage API base URL used to verify STA tokens in `embed-token` | | ||
|
|
Unifies endpoint, handler, and constant naming with the existing HandshakeClaims/MintHandshakeJWT/purposeHandshake symbols. The purpose claim value changes from "kai-preview-embed" to "kai-preview-handshake".
Replace the opaque "STA" prefix throughout the kai-preview feature with the clearer "StorageToken" naming. The interface carries the canonical name (StorageTokenVerifier), the HTTP-backed impl gets a kind-specific name (HTTPStorageTokenVerifier), and all fields, locals, tests, error strings, and docs are updated to match. Files renamed via git mv: sta_verifier.go → storage_token_verifier.go sta_verifier_test.go → storage_token_verifier_test.go
701444c to
9f4f8eb
Compare
…d helpers Move the four endpoint handlers (handshake-token, bootstrap, exchange, refresh), the composite Handler, and routing symbols (PathPrefix, DevModeChecker, DevModeCheckerFunc) into a new internal/…/kaipreview/endpoints sub-package (package name "endpoints"). The parent kaipreview package retains the pure helpers: JWT minting/ verification, cookie helpers, CORS, IsIframeDocumentLoad, and the StorageTokenVerifier interface (moved to storage_token_verifier.go). External callers (apphandler.go) import both packages: kaipreview for helpers and kpendpoints alias for the handler/routing surface.
Fix all 52 golangci-lint failures reported by CI run 25903961499: - noctx(25): replace httptest.NewRequest → NewRequestWithContext(t.Context(), …) in all kaipreview test files - tagliatelle(3): rename JSON tags app_id → appId and ttl_s → ttlS in jwt.go (HandshakeClaims, SessionClaims) - errchkjson(7): check errors from json.Marshal / json.NewEncoder().Encode() in bootstrap.go, handshake_token.go, exchange_test.go, storage_token_verifier_test.go - nilerr(4): add //nolint:nilerr with explanation where HTTP handlers deliberately swallow errors - gochecknoglobals(2): var PathPrefix → const PathPrefix in handler.go; //nolint:gochecknoglobals on bootstrapTmpl - gci(4): fix struct-field alignment in manager.go / apphandler.go; add missing spaces after commas in cookie_test.go, cors_test.go - contextcheck(2): add //nolint:contextcheck on the two false-positive req.Context() call sites in apphandler.go; extract serveKaiPreview helper to reduce nestif complexity - paralleltest(1)+tparallel(1): add t.Parallel() to TestIsIframeDocumentLoad subtests - testifylint(1): assert.False(strings.Contains(…)) → assert.NotContains(…) - errname(1): rename stubErr → stubError in handshake_token_test.go - gosec G203(1): //nolint:gosec on template.JS(bs) conversion in bootstrap.go - unparam(1): remove unused devMode bool param from newTestCompositeHandler; inline true - usetesting(6): context.Background() → t.Context() in storage_token_verifier_test.go
Replace GetFreePort()+Listen() with a direct Listen("127.0.0.1:0") so
the OS assigns a free port atomically, removing the TOCTOU window where
a parallel test could steal the port between the two calls.
d5cf011 to
133daa0
Compare
| data := struct { | ||
| AllowedIDEOriginsJSON template.JS | ||
| }{ | ||
| AllowedIDEOriginsJSON: h.originsJSON, | ||
| } |
There was a problem hiding this comment.
This is not doing deep copy or is it ? I think this is shallow which means it's same as using h.originsJSON
| assert.Equal(t, SessionCookieName, c.Name) | ||
| assert.Equal(t, jwt, c.Value) | ||
| assert.Equal(t, "/", c.Path) | ||
| assert.True(t, c.Secure) | ||
| assert.True(t, c.HttpOnly) | ||
| assert.Equal(t, http.SameSiteNoneMode, c.SameSite) | ||
| assert.True(t, c.Partitioned) | ||
| assert.Empty(t, c.Domain, "must be host-only — no Domain attribute") | ||
| assert.Equal(t, int(ttl.Seconds()), c.MaxAge) |
There was a problem hiding this comment.
Try to embed it into single assert.Equal(t, expectedCookie, c)
| // IsDevMode implements DevModeChecker. | ||
| func (f DevModeCheckerFunc) IsDevMode(ctx context.Context, appID string) bool { return f(ctx, appID) } | ||
|
|
There was a problem hiding this comment.
Why it has to be exported?
| } | ||
|
|
||
| func (v *STAVerifier) Verify(ctx context.Context, token string) (*STAVerifyResult, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.baseURL+"/v2/storage/tokens/verify", nil) |
There was a problem hiding this comment.
Why dont we use SDK for verify? 🤔
| type KaiPreview struct { | ||
| HandshakeSigningKey string `configKey:"handshakeSigningKey" configUsage:"HMAC key for kai-preview handshake JWT (30-60s lifetime)." validate:"required" sensitive:"true"` | ||
| SessionSigningKey string `configKey:"sessionSigningKey" configUsage:"HMAC key for kai-preview session cookie JWT." validate:"required" sensitive:"true"` | ||
| SessionTTL time.Duration `configKey:"sessionTTL" configUsage:"Lifetime of the kai-preview session cookie (sliding)."` | ||
| AllowedIDEOrigins []string `configKey:"allowedIdeOrigins" configUsage:"Origins allowed to mint kai-preview embed tokens (e.g. https://connection.keboola.com)." validate:"required,min=1,dive,http_url"` | ||
| } |
There was a problem hiding this comment.
Should be added to docker compose
| } | ||
|
|
||
| func (v *HTTPStorageTokenVerifier) Verify(ctx context.Context, token string) (*StorageTokenVerifyResult, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.baseURL+"/v2/storage/tokens/verify", nil) |
| panic("appsproxy: StorageAPIURL is required for kai-preview Storage token verification") | ||
| } | ||
| storageAPIURL := cfg.StorageAPIURL.String() | ||
| storageTokenHTTPClient := &http.Client{Timeout: 5 * time.Second} |
There was a problem hiding this comment.
Consider using SDK client it's predefined
|
@Matovidlo sorry, tohle jeste nemelo jit na ostry review, zatim PoC. Vracim do draftu a pozdeji poslu, az to bude ready. |
Release Notes
https://linear.app/keboola/issue/PAT-1788/kai-iframe-auth
Adds a dev-mode-only authentication path for data apps so kbc-ui can embed running apps in an iframe without going through the configured OAuth/Basic prompt. Direct (non-iframe) access to the same app still goes through the configured auth path. Gating is per-app via the existing
spec.devMode.enabledfield on theAppCRD; non-dev-mode apps see the new endpoints as404.The flow is four new endpoints under
/_proxy/kai-preview/:embed-token(CORS, called by SPA withX-StorageApi-Token— verifies via Storage API, mints a 60s scoped HS256 JWT),bootstrap(serves a postMessage handshake shim withframe-ancestorsCSP),exchange(verifies the JWT, sets a host-onlySameSite=None; Partitioned; HttpOnlysession cookie), andrefresh(CORS heartbeat that slides the cookie'sMax-Ageforward past the midpoint). All validation is stateless across replicas — HMAC-signed JWTs only, no shared store. The session cookie carries no identity claims; apps that need user identity should fall back toKBC_TOKEN(same contract as Basic-auth-protected apps today).Key changes:
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/package (~14 files, 65+ unit tests).apphandler.goadds three new routing branches gated onAppInfo.DevMode: kai-preview path → composite handler; valid session cookie → upstream with sliding refresh; iframe document load → bootstrap shim fallback.kaiPreview.handshakeSigningKey,kaiPreview.sessionSigningKey,kaiPreview.allowedIdeOrigins,storageApiUrl. Generate signing keys withopenssl rand -hex 32.Cross-repo follow-ups:
kbc-stacksHelm values need updating per stack — seedocs/apps-proxy/kai-preview.mdSection 4 for the required keys.Plans for customer communication
None.
Impact analysis
No end-user impact — the new endpoints are
404on apps wherespec.devMode.enabledis false (the default), and existing direct-access auth paths are unchanged. Operators must provision the new required signing keys andstorageApiUrlbefore deploying; otherwise the proxy panics at startup.Change type
New feature
Justification
PAT-1788 — enable embedding dev-mode data apps inside kbc-ui without breaking the existing auth model.
Deployment
Merge & automatic deploy.
Rollback plan
Revert of this PR.
Post release support plan
None.